feat(*): Migrate to useSyncExternalStore#7411
Conversation
…uth instead of at the ClerkProvider level
… react package to use it
Co-authored-by: Jacek Radko <jacek@clerk.dev>
…s-from-provider-and-rely-on
…baseRouter" This reverts commit 3f46a41.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@packages/ui/src/contexts/CoreClientContext.tsx`:
- Around line 5-21: The hooks useCoreSignIn and useCoreSignUp currently throw
when useClientContext() is falsy and that can happen during normal Clerk
loading; change them to mirror useSessionList's behavior by returning a safe
"not loaded" shape instead of throwing: call useClientContext(), and if client
is falsy return an object that signals isLoaded: false and includes the same
public API shape/fields expected from SignInResource/SignUpResource (e.g., no-op
methods or placeholders) so consumers can render during loading; update both
useCoreSignIn and useCoreSignUp to perform this guard and return the safe
fallback rather than throwing.
nikosdouvlis
left a comment
There was a problem hiding this comment.
I wish every PR had a description like this 👍🏻 Makes it much easier to understand the "why" behind the changes.
| // Avoid rendering nested ClerkProviders by checking for the existence of the ClerkNextOptions context provider | ||
| const isNested = Boolean(useClerkNextOptions()); | ||
| if (isNested) { | ||
| if (rest.initialState) { |
There was a problem hiding this comment.
Just double checking - is there any difference here between initialState being null vs undefined? The condition if (rest.initialState) would be false for both, but wondering if we need to handle them differently.
There was a problem hiding this comment.
Always good to double check! I don't think initialState: null is valid according to the types, and I do think the correct way to handle that case is to ignore it.
One might argue we'd want a way to reset the initialState for a subtree if you do something like:
<ClerkProvider dynamic>
<ClerkProvider>but I don't really see a case for that.
| const clerk = useClerkInstanceContext(); | ||
| const initialState = useInitialStateContext(); | ||
| const getInitialState = useCallback(() => { | ||
| return initialState?.session as SignedInSessionResource | undefined; |
There was a problem hiding this comment.
The cast initialState?.session as SignedInSessionResource is a bit unsafe since InitialState.session is typed as SessionResource | undefined. Probably fine in practice since session is always signed-in when present, but a type guard would be cleaner if we want to be strict about it.
There was a problem hiding this comment.
I'm pretty sure I just moved this cast from elsewhere, there were a bunch of those. I'll double check and will dig a bit deeper to see if I can clean it up a bit.
I've seen quite a few of these that doesn't feel great. 🤔
There was a problem hiding this comment.
This was from deriveFromSsrInitialState, which has a bunch of these and we should probably clean it up.
I refactored useSessionBase to call deriveFromSsrInitialState instead of using .session and casting directly. There's no real overhead to that and that way we at least keep the casts in one place.
| useCallback(() => { | ||
| return clerk.__internal_lastEmittedResources; | ||
| }, [clerk]), | ||
| ); |
There was a problem hiding this comment.
The useSyncExternalStore here doesn't provide a getServerSnapshot, which means it will throw during SSR. I assume the Components app is never server-rendered, but should we add a comment explaining that this cannot be server rendered at the moment? Would help future us if someone tries to SSR this later.
There was a problem hiding this comment.
I added this as the getServerSnapshot, together with a comment:
useCallback(() => {
return clerk.__internal_lastEmittedResources;
}, [clerk]),This will prevent it from throwing when we want to play around with SSR, and should actually be fine here since we don't use the returned state anyway.
| * Updates the accessors for the Clerk singleton and emits. | ||
| * If dangerouslySkipEmit is true, the emit will be skipped and you are responsible for calling #emit() yourself. This is dangerous because if there is a gap between setting these and emitting, library consumers that both read state directly and set up listeners could end up in a inconsistent state. | ||
| */ | ||
| #updateAccessors = (session?: SignedInSessionResource | null, options?: { dangerouslySkipEmit?: boolean }) => { |
There was a problem hiding this comment.
The dangerouslySkipEmit flag feels a bit scary. The JSDoc warning is good, but I wonder if it makes sense to add something in our CLAUDE.md about this pattern so future contributors (and Claude) know when it's safe to use?
There was a problem hiding this comment.
Good idea!
Note that the semantics for existing code is unchanged, so hopefully refactoring the emit into here and adding this "dangerous" option will make it clearer that gaps in timing between update and emit are dangerous. I agree we should do everything we can to get people to not use this for anything new. 😅
There was a problem hiding this comment.
This felt a bit specific/niche to litter CLAUDE.md etc with, but I expanded the comment a bit to advice against using the option for new code and listed an alternative approach.
| this.user = this.session ? this.session.user : null; | ||
| /** | ||
| * Updates the accessors for the Clerk singleton and emits. | ||
| * If dangerouslySkipEmit is true, the emit will be skipped and you are responsible for calling #emit() yourself. This is dangerous because if there is a gap between setting these and emitting, library consumers that both read state directly and set up listeners could end up in a inconsistent state. |
There was a problem hiding this comment.
🤔 We now have skipInitialEmit (on addListener) and dangerouslySkipEmit (on #updateAccessors). The difference is subtle: one controls whether a new subscriber gets an immediate callback, the other controls whether setting accessors triggers an emit. I wonder if there's a way to consolidate or simplify this flow in the future? Not blocking, but might be worth thinking about as a follow-up to reduce the number of "skip emit" concepts floating around.
There was a problem hiding this comment.
Agreed! I plan to implement @bratsos suggestion in a follow up, which should let me remove skipInitialEmit entirely. If you want that behavior, you'll be able to subscribe instead of addListener.
…t type assertions
…s-from-provider-and-rely-on
|
!allow-major |
Description
This is the full implementation of PoCs #7194 and #7267.
The main goal of the PR is to refactor the SDKs away from using a top level
addListener+state+contextcombo for auth-state, to usinguseSyncExternalStore.ContextProvidersfromuiandreactintoshared/reactclerk.addListener()in that provider, also removes most contextsUserContextare no longer exported fromshared/reactclerkviauseSyncExternalStoreuseUserBaseare also reexported asuseUserContextto avoid breaking change for theseMore implementation notes
useClearQueriesOnSignOutas a conditional hook was causing the new tests to failinitialAuthStatefromuseAuthalso fixes a bug where the initial auth state was being used during the transitive statenullinstead ofundefinedon the page right after sign-in, which put the app in an akward state where it momentarily believed you were still signed out - Could lead to redirects back to/sign-inthis.#setAccessors();tothis.#updateAccessors();which now callsthis.#emit()by defaultskipInitialEmitto skip emittingclerkstate and the app state tearthis.#emit()this.__internal_lastEmittedResources, souseSEScan read the last resources emitted ingetSnapshotskipInitialEmitto avoid emitting and re-rendering onuseSES->subscribeclerk-jsWith all this done, I noticed two timing issues in the bridge between the host React app and the Components app, which is a separate app. Both happened because components that should have unmounted had time to re-render and fire effects, because
useSESemitted slightly earlier thansetState.ui/src/Components.tsx, the nodes (current components and which node they should be portaled into) was being set viasetStateSignInRouteshas a fallback, which is to redirect back to/sign-inTanStack Routerspecifically,SignInstayed mounted and re-rendered because ofuseSES, even though the node was gone and the next page had already been rendered<RedirectToSignIn />routenodesa ref insteaduseSyncExternalStorewhere we don't use the return value, to make sure<Components />re-renders before the child and unmounts itBaseRouter.tsx, the asyncbaseNavigatewas doingsetRoutePartsas part of the navigationuseSESin a child ran first and similar to the above case, triggered a redirect back to/sign-influshSyncto ensure the components had properly re-rendered, guaranteeingsetActivedoes not leave the transitive state before the host app AND the Clerk components are readyBoth of these fixes might be considered controversial, happy for feedback!
Note that the unmount flow has multiple
.then()which schedules unmounting into several microtasks even whenUIis available. We might want to make this synchronous for even tighter control, but this was not necessary to fix these bugs.Note also that these bugs result from having multiple React apps. The normal React lifecycle should guarantee they don't happen in host apps.
Background and motivation
Feel free to skip this if you already have familiarity, but as this is a fairly large change, I wanted to write down the full reasoning for this change for posterity.
As I see it, Clerk has different kinds of state. JWT state, piggybacking state and fetched state. Worth noting is that the fetched state has previously used SWR under the hood, but is about to change over to a custom React Query implementation, but both of these already uses
useSyncExternalStoreand are untouched by this PR.The JWT state is fairly self-explanatory, and the client piggybacking state is asynchronous state that is included as part of other API-calls as a self-refreshing mechanism. The
clerk-jsbase abstraction updates this for example when it polls for the/tokens, and pushes the changes to the SDKs.The JWT state and the piggybacking state together represents the foundational "auth state". This represents the user, the active organization etc.
Current solution
clerk-jshas anaddListenermethod. The React SDKs subscribes to this in an effect in their<ClerkProvider>and sets an internal state on every update. This state is then massaged a bit and placed on a few different contexts, and the different hooks, likeuseAuth,useUseretc read from these contexts.<ClerkProvider>also supports passing in aninitialState. If this is passed in, you can access it during SSR, but because there is no good way to prefetch the piggybacking or fetched state currently, only theuseAuthstate is really feasible to server render.In Next, doing
<ClerkProvider dynamic>automatically provides theinitialStateforuseAuthbehind the scenes. The reason we don't always do this is that it opts out of static generation and caching.Current challenges and issues
Subscribing at the top has a few downsides however. It's currently hard to make things more streamy by allowing
initialStateto be represented as a promise, as we currently need to await it at the top. This is something we want to do to supportcacheComponentsand Partial prerendering better, allowing for finer-grained caching. This also opens up for making things suspenseful.It also doesn't play well with concurrent rendering. A concrete example of this is an active bug that can happen when one subtree suspends long enough during page load that
clerk-jshas time to load. When it unsuspends, it reads the loaded auth state and not the initial one, causing hydration mismatches.Another downside is how context is hard to use for fine grained updates, making it hard to implement things like selectors on top of it.
useSyncExternalStoreis the canonical way to safely subscribe to an external store, and has none of these downsides.Testing PR: https://github.com/clerk/dashboard/pull/7994
Checklist
pnpm testruns as expected.pnpm buildruns as expected.Type of change
Summary by CodeRabbit
Breaking Changes
New Features
Bug Fixes
✏️ Tip: You can customize this high-level summary in your review settings.